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.
This commit is contained in:
daniel-c-harvey
2026-06-15 23:30:28 -04:00
parent 22c2ae5ecb
commit e9f4411fdf
2 changed files with 560 additions and 195 deletions
+27 -15
View File
@@ -25,11 +25,19 @@ These were flagged during the audit but classified as feature work, not defect f
### 1.3 Preload / prefetch of the next track
- **What:** No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
> **Split as of 2026-06-15.** This item bundled two things: (a) a **queue model** ("a notion of next
> track") and (b) **preload/prefetch** (begin the next track's bytes during the current tail). The
> **queue half (a) is now absorbed into Phase 11** (commitment 7 — Daniel: "now is the natural time
> for that"; full spec in `product-notes/phase-11-public-site-enhancements.md §3c`). The **preload
> half (b) remains deferred here** and still gates crossfade (1.4) and gapless (1.5). The open
> question below — queue in `IPlayerService` vs. a separate orchestrator — is **answered in the
> Phase 11 spec** (strong steer: a separate `IQueueService` above the single-slot player; final call
> staff-engineer's at implementation). When Phase 11's queue lands, the preload below becomes "add a
> subscriber to the queue's already-known next track," not a fresh queue design.
- **What (deferred — preload only):** No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
- **Why it matters:** Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight.
- **Shape:** A second `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next.
- **Prerequisite:** Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in `IPlayerService` or a passive "what was the next row in the gallery" inference.
- **Open question:** Does a queue model belong in `IPlayerService`, or is the player a single-slot device that a future `PlaylistService` orchestrates above? Worth a design note before implementation. Capture in product notes when picked up.
- **Shape:** A second `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. **The "next track" it prefetches comes from Phase 11's `IQueueService`** — that dependency is now satisfied by the queue work, not an open question.
### 1.4 Crossfade
@@ -194,24 +202,28 @@ Full design, renderer architecture, the four effects, acceptance criteria, and p
## Phase 11 — Public Site Enhancements
The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **make the release the cardinal unit of every public navigation, and make every navigation an addressable, shareable URL.** Four Daniel commitments — two structural (a Cuts detail page; the release-title click resolves medium → dedicated detail page), two normalization/polish (retire the redundant `/tracks?album` view; encode Archive filters in the URL). Full design, framing corrections, wave decomposition, and the gap analysis: `product-notes/phase-11-public-site-enhancements.md`.
The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **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). **Seven Daniel commitments** (the original four plus three added 2026-06-15 when he resolved the open questions and expanded scope): (1) a Cuts detail page `/cuts/{id}`; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the **whole** track-cardinal stack **and** normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3). Full design, framing corrections, wave decomposition, gap analysis: `product-notes/phase-11-public-site-enhancements.md`.
**State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). The two real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}` — the track gallery filtered), and **`/archive` holds its filters in component fields, not the URL**.
**State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). `ReleaseGallery` is the shared release-card grid — but **only Sessions/Mixes use it**; Archive and Cuts re-implement equivalent card markup inline. The real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}`), and **`/archive` holds its filters in component fields, not the URL**. The queue/playlist **does not exist** (single-slot player).
**Three framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** the track-cardinal detail is `/track/{EntryKey}`, and the player-bar title links there. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** applied at click sites (the player bar already carries the release id + medium, so no round-trip is needed), plus a thin `/tracks/{id}` redirect page for bare-release-id deep links. (2) The new `/cuts/{id}` album page is the phase's center of gravity — the first **multi-track** release detail. (3) Requirement 4 is a **URL-binding pass over the existing `ArchiveView`**, borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern verbatim — not a new browser.
**Headline correction — commitment 5 is already built.** The brief framed the track ordinal as a new column + EF migration + a Daniel-gated apply step. **The read shows it already shipped in Phase 8:** `TrackEntity.TrackNumber` (1-based, non-null), migration `20260611005700` **already applied**, `TrackDto` mirror, API write path (validated `> 0`), CMS reorder (`BatchEdit` assigns ordinal from list position on submit), and the read already `.OrderBy(t => t.TrackNumber)`. **No new schema, no migration to gate.** Commitment 5 collapses to *verify-and-consume*: confirm the public read projects/sorts `TrackNumber` and that `CutDetailViewModel` orders by it (a one-line fix if not). See spec §3a.
**Design discipline.** The medium→route resolver is **one table** (promote `ArchiveView.DetailHref` to a shared `ReleaseRoutes` helper) consumed by the player bar, Archive cards, and Cuts cards — not three switches (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + new `BodyContent` slot for the track list **not** a boolean layout flag (Phase 9 §5.3's named rule: layout variance rides a slot, never a flag). The Archive URL-binding makes the URL the source of truth (history-driven re-fetch in `OnParametersSet`), so back/forward + shareable links fall out structurally.
**Framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** — the track-cardinal detail is `/track/{EntryKey}`. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** at click sites (the player bar already carries release id + medium — no round-trip), plus a thin `/tracks/{id}` redirect page for deep links. (2) The new `/cuts/{id}` album page is the phase's center of gravity — the first **multi-track** release detail. (3) Requirement 4 is a **URL-binding pass over the existing `ArchiveView`**, borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern — not a new browser.
Sequenced as four tracks across two parallel waves.
**Design discipline.** The medium→route resolver is **one table** (`ReleaseRoutes.DetailHref`) consumed by the player bar, Archive, and Cuts cards. The shared `ReleaseGallery` becomes the **one** release-card grid across all four browse surfaces (Archive/Cuts fold in via a new per-card `HrefResolver`), not three inline copies (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + a `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3). The queue is a separate `IQueueService` orchestrating above the single-slot player (strong steer; final call staff-engineer's). Header Play binds to a single handler that swaps single-track → `QueueService.PlayRelease` with no page change (memory *Design for adaptability up front*).
- **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list with per-row play, header Play → track 1. New `CutDetailViewModel`; reuses `GetById` + the existing `releaseId`-filtered track page (both exist). **Load-bearing prerequisite for 11.B's Cut resolution.**
- **11.B — medium→route resolver + repoint.** Shared `ReleaseRoutes.DetailHref`; Cut resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title, Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.**
- **11.C — normalization / reduction.** Retire the `/tracks?album` filter branch (safe once 11.B repoints both consumers). Decide with Daniel whether to retire the whole track-cardinal stack (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`) now that Cuts have a proper detail page and `/tracks` is already nav-demoted — the phase's biggest reduction lever. Consolidate duplicate medium-label lookups. **Depends on 11.B.**
- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`. Fully independent — touches only `ArchiveView`. **No dependency; free-floating.**
Sequenced as **six waves**; the critical path is `11.A → 11.B → 11.C`, with 11.D / 11.E / 11.F hanging off it.
**Dependency shape:** `11.A → 11.B → 11.C`; **11.D parallel**.
- **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by `TrackNumber`) with per-row play, header Play. New `CutDetailViewModel`; reuses `GetById` + the `releaseId`-filtered track page (both exist). Ordinal is a **verification** (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§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 resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title (→ release), Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.**
- **11.C — retire + normalize (the heart).** With §2 removing every inbound link: **delete the whole track-cardinal stack** (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/`GalleryViewMode` + `/tracks`, `/track/{EntryKey}` routes) **and** fold Archive + Cuts inline cards into the shared `ReleaseGallery` (new `HrefResolver`); consolidate the medium-label lookup. **Depends on 11.B.** (Cut track-row is a separate small `TrackRow`, not `ReleaseGallery`.)
- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Touches only `ArchiveView`. **Free-floating — but coordinate with 11.C** (both edit `ArchiveView`).
- **11.E — release-level Share.** `SharePopover` gains a release mode that copies `ReleaseRoutes.DetailHref(release)`; wire the Cut header Share to it. **Depends on 11.B** (resolver) + a release detail to share.
- **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature.
**Open questions (Daniel — in the spec §7):** player-bar title now points at release detail not track detail (recommend yes — it is the premise of req 1); `/cuts/{id}` scaffold strategy (generalized `Header` slot vs. bespoke page); Cut header affordance idiom (icon vs. labeled buttons); **track ordering**`TrackEntity` has no `TrackNumber` column, so album order is insertion/`Id` order today (recommend ship on insertion order, raise `TrackNumber` as its own small phase only if needed — `[do not assume into Phase 11]`); `/tracks` retirement scope (album-branch only vs. whole track-cardinal stack). **Adjacent gaps surfaced, not in scope:** the Cuts album page is the strongest case yet for the deferred **play queue** (`§1.3`) — "play album" is the expected affordance; design header Play so a future enqueue-album is a handler swap, not a rewrite. `SharePopover` is track-keyed — a Cut header Share arguably shares the **release** URL, a new share target.
**Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D and 11.F parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A). The two cold-start items are **11.A** and **11.F** — kick both off first so "play album" works on first ship of the Cut page.
**Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); `/genres` fate (out of scope, flag as adjacent).
---
+533 -180
View File
@@ -1,13 +1,14 @@
# Phase 11 — Public Site Enhancements
Status: spec / design. Author: product-designer. Date: 2026-06-15.
**Plan only — no code edits made by this doc.**
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), `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*.
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*.
---
@@ -19,14 +20,42 @@ the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tra
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 four commitments. They share one spine: **make the release the cardinal unit of the public
site, and make every navigation an addressable, shareable URL.** Two of the four are structural
(a Cuts detail page; `/tracks` collapses to a router); two are normalization/polish (retire the
`/tracks?album` view; encode Archive filters in the URL).
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:
This is not a greenfield phase — most of the scaffolding it needs already exists. The job is
**closing the one asymmetry Phase 9 left** (Cuts are the only medium with no single-release detail
page) and **finishing the URL-addressability story** the Archive browser started.
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 Share****new scope**; a Cut/Session/Mix header Share shares the release URL.
7. **Play-queue system****new 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)
@@ -36,10 +65,15 @@ page) and **finishing the URL-addressability story** the Archive browser started
| `/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}`. | `Pages/AlbumsView.razor` |
| `/archive` browser | **Exists, release-cardinal.** Debounced search + medium chips + genre filter. Cards route per-medium. **Filters held in component fields, NOT in the URL.** | `Pages/ArchiveView.razor(.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)
@@ -69,26 +103,55 @@ these up front so the implementer is not misled:
---
## 1. The four commitments (Daniel, faithful capture)
## 1. The seven commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)
1. **`/tracks/{id}` becomes a pure router** → resolve a release's `ReleaseMedium` to the correct
dedicated detail page (`/cuts/{id}` | `/sessions/{id}` | `/mixes/{id}`). Hit only when a user
clicks the release title in the player bar; its sole job is medium → page resolution.
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**, each row with a play button.
- Header Play starts the **first track**.
- 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. **Code normalization / reduction.** Eliminate the now-redundant `/tracks?album` page view (the
Archive provides all filtering/searching, so `/tracks` collapses to a pure router). Identify and
normalize other UI redundancy.
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
@@ -133,12 +196,13 @@ reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router
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.
> **Open question (Daniel):** the player-bar title currently links to **`/track/{EntryKey}`** (the
> track-cardinal `TrackDetail`). The brief says the title click should resolve medium → dedicated
> page. **Confirm the player-bar title should now point at the release detail (via the resolver),
> not the track detail.** If yes, `TrackMetaLabel`'s `<a href>` changes from `/track/{EntryKey}` to
> `ReleaseRoutes.DetailHref(Track.Release)` — and `TrackDetail`'s role shrinks (see §4, the
> normalization question on whether `TrackDetail` survives at all).
> **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
@@ -239,12 +303,12 @@ The Cut page needs the release **and its ordered tracks**. Both primitives exist
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.** The track list must render "in order." Verify the track page's default sort yields a
stable, meaningful order for an album (track-number if it exists; otherwise insertion/`Id` order).
*Open question:* does `TrackEntity` carry a track-number / ordinal field? From the current schema it
does **not** (`Id, EntryKey, TrackName, Artist, Album, Genre, ReleaseDate, ImagePath`). If album
track order matters beyond insertion order, that is a **data-model gap** (a `TrackNumber` column) —
flagged in §7 as a Daniel decision, *not* assumed into this phase.
**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:
@@ -263,77 +327,306 @@ medium conditional into the base. Flag the choice; don't pre-commit the implemen
### 3.4 Play wiring
- **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current,
else `SelectTrackStreaming`).
- **Header Play:** stream **track 1** (`Tracks.FirstOrDefault()`). If a queue/playlist model existed
(`PLAN.md §1.3`), header Play would enqueue the whole album — **it does not today**, so header Play
starts track 1 and the rest is manual. *Adjacent opportunity, not in scope* — see §7.
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.
---
## 4. Requirement 3normalization and reduction
## 3a. Commitment 5the track-ordinal column (already built; verify-and-consume)
The brief: eliminate the `/tracks?album` page view; `/tracks` collapses to a pure router; identify
other redundancy.
**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.
### 4.1 The `/tracks?album` retirement is gated on §2 and §3
### 3a.1 What already exists (each verified, 2026-06-15)
`/tracks?album={title}` has **two live consumers** today:
| Layer | State | Evidence |
|---|---|---|
| Entity | `TrackEntity.TrackNumber``int`, 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_AddReleaseTypeAndTrackNumber`**already 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` |
- `AlbumsView` (`/cuts`) cards → `OpenAlbum``/tracks?album={title}` (line 62).
- `ArchiveView` Cut cards → `DetailHref``/tracks?album={title}` (line 125).
### 3a.2 The reorder-UX question Daniel asked me to surface — already answered
Both must be **repointed to `/cuts/{id}`** before the `?album=` view can be retired. So the ordering
is: §3 (`/cuts/{id}` exists) → §2 (resolver returns `/cuts/{Id}` for Cut) → §4.1 (repoint both
consumers) → **then** the `?album=` filter branch in `TracksView` can be removed.
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.
> **Scope subtlety.** "Eliminate the `/tracks?album` page view" means remove the **album-filtered
> *mode*** of the track gallery, not necessarily the whole `/tracks` route. `TracksView` also
> supports `?genre=` and `?q=` and a plain unfiltered gallery. Decide (Daniel) whether:
> - **(A)** remove only the `album` query branch (keep `/tracks` reachable with genre/search), or
> - **(B)** retire `TracksView` **entirely** — the Archive (§release-cardinal search) subsumes it,
> and Phase 9 §8.I already demoted `/tracks` from the nav. Cuts now have a proper detail page;
> the track-cardinal gallery may have no remaining job.
>
> **Recommend (B) as the eventual target, (A) as the safe first step.** If `/cuts/{id}` + `/archive`
> cover every browse/play path, `TracksView`, `TrackDetail`, `TrackCard`, `TracksGallery`, and
> `GalleryViewMode` become removable surface — a meaningful reduction. But that is a bigger cut than
> the brief literally asks; confirm before deleting. See §4.3.
### 3a.3 The one real verify step — the *public* read projection
### 4.2 What `TrackDetail` becomes
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.**
If §2's resolver repoints the player-bar title to the **release** detail (the recommended
direction), `TrackDetail` (`/track/{EntryKey}`) loses its only inbound link from the player bar.
`TrackCard` still links to it (`/track/{EntryKey}`). If `TracksView`/`TrackCard` are retired (4.1
option B), `TrackDetail` has **no inbound links** and is dead surface — retire it too. If `/tracks`
survives (option A), `TrackDetail` stays as the track-cardinal detail.
> **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.
**This is the cleanest reduction lever in the phase:** the track-cardinal stack (`TracksView`,
`TrackDetail`, `TrackCard`, `TracksGallery`) exists from the pre-medium era. Phase 9 made the release
cardinal; Phase 11's Cuts page removes the last reason a Cut needed the track gallery. Whether to
pull the whole stack is a Daniel call (§7), but the phase should **name the dead surface explicitly**
so the reduction is deliberate, not accidental.
---
### 4.3 Candidate redundancy inventory (for the normalization pass)
## 3b. Commitment 6 — release-level Share
Surfaced from the read; each is a *candidate*, not a commitment:
**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.
1. **`/tracks?album` filter branch** — retire (gated, §4.1). **High confidence.**
2. **`TracksView` + `TrackCard` + `TracksGallery` + `GalleryViewMode`** — track-cardinal gallery
stack. Removable iff Cuts page + Archive cover all paths (§4.1 B). **Daniel decision.**
3. **`TrackDetail` (`/track/{EntryKey}`)** — removable iff its inbound links go (§4.2). **Follows 2.**
4. **`GenresView` (`/genres`)** — already demoted from nav (§8.I), route kept. The Archive has a
genre filter. Is `/genres` still needed? Probably retire-able, but **out of this phase's stated
scope** — flag as adjacent. **Daniel decision, low urgency.**
5. **`AlbumsView` `OpenAlbum`** — repointed from `/tracks?album` to `/cuts/{id}` (§4.1). **Part of
the work, not removable.**
6. **`ArchiveView.DetailHref` + `ArchiveView.MediumLabel`** — `DetailHref` promotes to the shared
`ReleaseRoutes` resolver (§2). `MediumLabel` is a label lookup; check whether it duplicates the
CMS `MediumTypeLabels` (§8.D) or the Archive's own medium-chip labels — if the public side has two
medium-label lookups, **consolidate to one**. **Medium confidence.**
### 3b.1 What `SharePopover` does today
> The phase should land **(1)** for sure, **decide (2)+(3) as a pair** with Daniel, and treat
> **(4)+(6)** as opportunistic tidy-ups. Do not let the reduction sprawl — normalize what the four
> commitments *touch*, surface the rest as adjacent.
`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 track**`SelectTrackStreaming(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.
---
@@ -401,104 +694,148 @@ seed-from-URL step just has to run before the restore decision (as §5.2 specifi
## 6. Wave decomposition
Sequenced so the structural dependency (Cuts page → resolver → repoint → retire) is honored, and the
two independent tracks (Archive URL; Cuts page) can run in parallel.
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.
```
Wave A (parallel start) Wave B (parallel start)
┌──────────────────────────┐ ┌────────────────────────────┐
│ 11.A /cuts/{id} page │ 11.D Archive filters →URL
│ + CutDetailViewModel │ (TracksView-pattern bind)
│ + cover theme border │ └────────────────────────────┘
│ + ordered track list │ (fully independent —
└──────────┬───────────────┘ touches only ArchiveView)
┌──────────────────────────┐
│ 11.B medium→route │
│ resolver (ReleaseRoutes) │
│ + thin /tracks/{id} │
│ redirect page
+ repoint player-bar
title + Archive +
AlbumsView cards
└──────────┬───────────────┘
──────────────────────────┐
│ 11.C Normalization:
│ retire /tracks?album │
│ branch; decide + (maybe)
│ retire track-cardinal
stack; consolidate
│ medium-label lookups
└──────────────────────────┘
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ 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}` detail page.** The new page, its VM, the cover theme border, the ordered
track list, header Play→track 1, row Play. Independent of everything except the existing
`GetById` + `releaseId`-filtered track page (both exist). **Load-bearing prerequisite for 11.B's
Cut resolution.**
- **11.B — medium→route resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared
`ReleaseRoutes` helper; Cut now resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title,
Archive cards, and `AlbumsView` cards through it; add the thin `/tracks/{id}` redirect page.
**Depends on 11.A.**
- **11.C — normalization.** Retire the `/tracks?album` filter branch (safe once 11.B repoints both
consumers); execute the agreed reduction from §4.3 (decide 4.3#2/#3 with Daniel first). **Depends
on 11.B.**
- **11.D — Archive filters in the URL.** Fully independent — touches only `ArchiveView`. Can land
first, last, or alongside Wave A. **No dependency.**
- **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).
**Critical path:** 11.A → 11.B → 11.C. **11.D is free-floating.**
**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. Open questions and adjacent gaps (need Daniel)
## 7. Resolved decisions and remaining open questions
**Decisions inside the four commitments:**
### 7.1 Resolved by Daniel (2026-06-15) — kept visible as *decided*, not deleted
1. **Player-bar title target (§2).** Confirm the title click should resolve **release** detail (via
the resolver), replacing the current `/track/{EntryKey}` link. *Recommend yes* — it is the premise
of requirement 1.
2. **`/cuts/{id}` scaffold strategy (§3.2).** Compose `ReleaseDetailScaffold` with a generalized
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.*
3. **Cut header affordance idiom (§3.2.2).** Keep the icon `PlayStateIcon`+`SharePopover` (consistent
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.*
4. **Track ordering / `TrackNumber` (§3.3).** Does an album need explicit track ordering beyond
insertion order? If yes, that is a **`TrackEntity.TrackNumber` data-model addition** (new column +
migration + CMS field + ordering in the read) — a real schema gap. *Recommend: ship 11.A on
insertion/`Id` order; raise `TrackNumber` as its own small phase if hands-on use shows albums
landing out of order.* **Daniel call — do not assume into Phase 11.**
5. **`/tracks` retirement scope (§4.1).** Remove only the `album` branch (A, safe) vs. retire the
whole track-cardinal stack — `TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery` (B, bigger
reduction). *Recommend B as the target, A as the floor.* This is the phase's biggest reduction
decision.
6. **`/genres` fate (§4.3#4).** Already nav-demoted; Archive has genre filtering. Retire `/genres`
too? *Out of stated scope — flag as adjacent, low urgency.*
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.
**Adjacent gaps surfaced by the read (not in the four commitments — for the roadmap, not this
phase):**
### 7.3 Small things to get right (not decisions — implementer notes)
- **No album-play queue.** Header Play starts track 1 only; there is no "play the whole album"
because the player is single-slot (no queue — `PLAN.md §1.3` preload/queue is still deferred). A
Cuts album page is the **strongest product argument yet for a queue model** — "play album" is the
expected affordance on an album page. *Recommend surfacing this as a renewed case for §1.3, not
building it in Phase 11.* The Cut page's Play button is the natural future entry point; design the
header Play so a future "enqueue album" is a swap of the handler, not a rewrite (the
*Design for adaptability up front* seam).
- **Share on a Cut shares a track, not the release.** `SharePopover` takes an `EntryKey` (a track).
A Cut's header Share should arguably share the **release URL** (`/cuts/{id}`), not a single track's
embed. *Flag:* the existing `SharePopover` is track-keyed; sharing a release is a new share target.
Decide whether the Cut header Share shares the release page or punts to track-share. *Recommend
release-URL share for the Cut header* — but it is a `SharePopover` extension, so flag it.
- **`TrackDto.Release` nullability on the player bar.** `TrackMetaLabel` guards `Track.Release?`
if a track ever loads without its release populated, the resolver (§2) has no medium to route on.
Verify the player's `TrackDto` always carries `Release` (the streaming select path). *Low risk;
worth a verification line in 11.B.*
- **No `ReleaseDate`-only-year display helper.** The Cut header shows "release year"; `MixDetail`/
`SessionDetail` show "MMMM yyyy". Minor inconsistency — the Cut header wants just the year per the
spec. *Trivial; note it so the implementer doesn't copy the month-year format.*
- **`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.
---
@@ -509,9 +846,15 @@ phase):**
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 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*.)
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.
@@ -544,8 +887,18 @@ phase):**
`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` has no track-number / ordinal field** (`Id, EntryKey, TrackName, Artist, Album,
Genre, ReleaseDate, ImagePath`). Album track ordering beyond insertion order is a data-model gap.
- **`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.