diff --git a/PLAN.md b/PLAN.md
index 9e8bfca..d633fd9 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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).
---
diff --git a/product-notes/phase-11-public-site-enhancements.md b/product-notes/phase-11-public-site-enhancements.md
index 1f6d6d9..25fd393 100644
--- a/product-notes/phase-11-public-site-enhancements.md
+++ b/product-notes/phase-11-public-site-enhancements.md
@@ -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 ``. **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 (1–4) plus three Daniel added when he resolved the open questions (5–7).
+
+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 `` 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 `` 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 3 — normalization and reduction
+## 3a. Commitment 5 — the 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` 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 Items // ordered; the release's tracks for "play album"
+ int CurrentIndex // -1 when empty
+ TrackDto? Current => Items[CurrentIndex]
+ bool HasNext / HasPrevious
+ PlayRelease(IEnumerable tracks, int startIndex = 0) // Cuts header Play / row Play
+ Enqueue(TrackDto) / EnqueueRange(IEnumerable)
+ 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? 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.