docs(plan): shape Phase 11 — Public Site Enhancements

Add Phase 11 to PLAN.md and a full design spec under product-notes:
Cuts gain a /cuts/{id} album detail page; release-title click resolves
medium to a dedicated detail page; redundant /tracks?album view retired;
Archive filters move into the URL. Includes gap analysis and open
questions for Daniel.
This commit is contained in:
daniel-c-harvey
2026-06-15 23:09:16 -04:00
parent e0f371cda6
commit 913861860b
2 changed files with 574 additions and 0 deletions
@@ -0,0 +1,551 @@
# Phase 11 — Public Site Enhancements
Status: spec / design. Author: product-designer. Date: 2026-06-15.
**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*.
---
## 0. Why this phase exists, and the state it inherits
Phase 9 made the medium taxonomy real end-to-end and gave each medium a browse + detail surface.
Wave 8 then remediated the public side: `/archive` became a release-cardinal searchable browser,
the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tracks` gallery was
demoted from the nav (route kept). That work landed and is stable on dev (2026-06-13/14).
Phase 11 is the **next coherent pass over the public listening surface**. Daniel's hands-on use
surfaced 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).
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.
### What already exists (verified against live source, 2026-06-15)
| Surface | State | File |
|---|---|---|
| `/sessions/{id}` detail | **Exists, mature.** Hero-dominant overlay; bridged prerender; play wiring. | `Pages/SessionDetail.razor` |
| `/mixes/{id}` detail | **Exists, mature.** Full-page WebGL waveform background + scaffold; bridged prerender. | `Pages/MixDetail.razor` |
| `ReleaseDetailScaffold` | **Exists.** Invariant trio (back link, masthead, play/share) + `Hero`/`MetaContent` slots. Composed by `TrackDetail` and `MixDetail`. | `Controls/ReleaseDetailScaffold.razor` |
| `ReleaseDetailBase` | **Exists.** Shared load + prerender-bridge for single-release detail pages (id-addressed, resolves the playable track via `releaseId`-filtered track page). | `Pages/ReleaseDetailBase.cs` |
| `/cuts` gallery | **Exists** as `AlbumsView` (medium-parameterized card grid). Cards open `/tracks?album={title}`. | `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)` |
| `/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` |
### Three framing corrections (the brief's vocabulary vs. the live routes)
The brief's framing is directionally right but uses route names that do not match the code. Naming
these up front so the implementer is not misled:
1. **There is no `/tracks/{id}` route.** The track-cardinal detail route is **`/track/{EntryKey}`**
(`TrackDetail`), keyed by the FileDatabase entry key (a string), not a numeric id. The player-bar
title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9). When the brief says
"`/tracks/{id}` becomes a pure router," the *intent* is: **the thing a release-title click lands
on should resolve medium → the correct dedicated detail page.** See §2 for what this actually
means against the live routes — the brief's "router" is better realized as a small **medium→route
resolver** than as a literal `/tracks/{id}` page, because the player bar carries a track, not a
release id.
2. **Cuts genuinely have no single-release detail page.** This is the real asymmetry. `/cuts`
(`AlbumsView`) cards and `/archive` Cut cards both open `/tracks?album={title}` — the track
gallery filtered to the album title. Sessions and Mixes route to dedicated `/sessions/{id}` /
`/mixes/{id}` pages. The new `/cuts/{id}` page (§3) closes this gap and is the heart of the phase.
3. **`/archive` filters are already in component state but not the URL.** `ArchiveView` holds
`_selectedMedium`, `_selectedGenre`, `SearchText` as private fields with no
`[SupplyParameterFromQuery]` and no `NavigateTo` on filter change. Requirement 4 is therefore a
**URL-binding pass over an existing browser**, not a new feature. The pattern to borrow already
lives next door: `TracksView` reads `?album=`/`?genre=`/`?q=` via `[SupplyParameterFromQuery]`
(`TracksView.razor.cs` lines 2123).
---
## 1. The four commitments (Daniel, faithful capture)
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.
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**.
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.
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).
---
## 2. Requirement 1 reframed — the medium→detail resolver
**Recommendation: do not build a literal `/tracks/{id}` page. Build a `medium → detail route`
resolver and apply it at the two click sites that need it.** Here is why, and the three shapes
considered.
### What the player-bar title actually carries
The player bar holds a **`TrackDto`** (`TrackMetaLabel.razor`). A `TrackDto` carries a nested
`Release` (with `Id` and `Medium`). So at the click site we already know the release id *and* the
medium — there is no need for a round-trip "router page" that fetches the release to discover its
medium. The resolver is a pure function:
```
Release { Id, Medium } → "/cuts/{Id}" | "/sessions/{Id}" | "/mixes/{Id}"
```
### Three shapes
- **(i) Literal `/tracks/{id}` router page.** A routable component that fetches the release by id,
reads `Medium`, and `NavigateTo`s the dedicated page (`replace: true` so the router never sits in
history). *Cost:* a network round-trip and a flash of an empty page on every release-title click,
to rediscover a medium the caller already knew. Justified **only** if some entry point has *only*
a release id and not the medium (e.g. an external deep link to `/tracks/{id}`). *Keep this as a
thin fallback, not the primary path.*
- **(ii) Pure resolver helper, applied at click sites (RECOMMENDED).** A single
`ReleaseRoutes.DetailHref(ReleaseDto)` (or `(long id, ReleaseMedium medium)`) helper — **one
table, one location** — that every release-title / release-card click consumes. `ArchiveView`
already has a private `DetailHref` switch (lines 121126); **promote it to the shared helper** so
the Archive, the player bar, and the new Cuts cards all route through one source. No round-trip,
no flash. This is the *One source, multiple views* discipline applied to routing.
- **(iii) Both.** The resolver helper (ii) is the primary path; a thin `/tracks/{id}` redirect page
(i) exists as the addressable fallback for bare-release-id deep links and for honoring the brief's
literal route. The redirect page consumes the *same* resolver helper.
**Recommend (iii): resolver helper as the spine, plus a thin `/tracks/{id}` redirect page that
reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router" wording *and* gives
the common case (player-bar click, where the medium is already in hand) a zero-round-trip path. The
redirect page is ~15 lines and shares the resolver, so it is not a second source of truth.
> **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).
### What "resolver" means for Cut
`DetailHref` for a Cut today returns `/tracks?album={title}`. After §3 lands, a Cut resolves to
**`/cuts/{Id}`** (id-addressed, consistent with Session/Mix). This repoint is the hinge between
requirements 1, 2, and 3 — see the dependency note in §6.
---
## 3. Requirement 2 — the `/cuts/{id}` album-detail page
This is the phase's center of gravity: the first **multi-track** release detail page. Sessions and
Mixes are single-track (their detail pages show one play affordance); a Cut is an album/EP/single
with an ordered track list.
### 3.1 Layout (Daniel's spec, literal)
```
┌─────────────────────────────────────────────────────────────┐
│ ← All cuts │
│ │
│ ┌─────────────────────────────┐ ┌──────────────────────┐ │
│ │ RELEASE NAME (h3) │ │ │ │
│ │ Artist (h6, primary) │ │ COVER ART │ │
│ │ Genre · 2025 │ │ (large-ish, │ │
│ │ │ │ theme border) │ │
│ │ [ ▶ Play ] [ ⤴ Share ] │ │ │ │
│ └─────────────────────────────┘ └──────────────────────┘ │
│ ↑ header content LEFT ↑ cover RIGHT │
│ │
│ ───────────────────────────────────────────────────────────│
│ 1. ▶ Track One 3:42 │
│ 2. ▶ Track Two 4:18 │
│ 3. ▶ Track Three 2:55 │
│ … │
└─────────────────────────────────────────────────────────────┘
```
- **Header left:** release name, artist, genre, release year, **Play** + **Share** buttons.
- **Cover right:** large, **theme border** around the image (a `deepdrft-`-prefixed border using a
palette token — mirror the existing cover treatments but with an explicit framed border, which is
the new visual element).
- **Track list below:** ordered rows, each with a play button. Row click / row play streams that
track.
- **Header Play** starts **track 1**.
### 3.2 Compose `ReleaseDetailScaffold`, or not? — the load-bearing design call
Phase 9 §5.3 established `ReleaseDetailScaffold` as the shared detail scaffold and committed to
"refactor `TrackDetail` onto it; per-medium variance rides slots." The scaffold owns the **invariant
trio**: back link, masthead (title + artist), play/share affordance. `MixDetail` composes it;
`SessionDetail` deliberately diverges (overlay layout). The question for Cuts:
- **(i) Compose the scaffold.** The Cut header *is* the invariant trio (title, artist, play, share) —
almost exactly what the scaffold provides. The cover goes in the `Hero` slot; the genre/year go in
`MetaContent`; the **track list** rides a new `BodyContent` slot (the scaffold has no body slot
today — adding one is the cheap, correct extension Phase 9 §5.3 anticipated: *"named slots are fine
where genuinely needed, e.g. `BodyContent` for the Cut/Album multi-track listing"*).
- **(ii) Bespoke page** (like `SessionDetail`). Full control over the left/right header split, but
duplicates the play/share wiring and the back-link/masthead the scaffold already owns.
**Recommend (i): compose the scaffold, add one `BodyContent` slot.** Two caveats that decide whether
this is clean or a fight:
1. **The header layout is left-content / right-cover; the scaffold's masthead is a top row.** The
scaffold today renders masthead-then-Hero vertically (`MixDetail` stacks cover below the
masthead). The Cut layout wants masthead and cover **side by side**. This is a **layout variance**,
and Phase 9 §5.3 is explicit: *"a boolean layout parameter on the scaffold is a design failure —
that variance belongs in a slot."* So the right answer is **not** a `HeroBesideMeta` flag on the
scaffold. Two clean options:
- **(a)** The Cut page supplies its whole left+right header as the page content and uses the
scaffold only for the back link + play/share wiring + body slot — i.e. the scaffold's masthead
is *one* arrangement and the Cut wants a different one, so the Cut composes a richer header into
a slot. Risk: the scaffold's built-in masthead then competes with the Cut's own header.
- **(b)** Generalize the scaffold's header region into a `Header` slot with the current
masthead+play row as the default content, so `MixDetail`/`TrackDetail` are unchanged (default)
and `CutDetail` supplies a left/right `Header`. This is the cleaner *One source* move but
touches the scaffold's shared contract.
- **Recommend (b)**, but flag it: it is a scaffold-contract change that ripples to every composer.
If Wave pressure makes (b) risky, **(ii) bespoke page** is the honest fallback — record it as
deliberate divergence (as `SessionDetail` already is) rather than bending the scaffold with a
boolean.
2. **The play/share affordance differs.** The scaffold renders a `PlayStateIcon` (icon toggle) +
`SharePopover`. Daniel's Cut spec says **Play and Share buttons** (labeled buttons, per the
mockup). If the Cut wants text buttons rather than the icon idiom, that is a **slot for the
affordance row**, not a scaffold edit. Minor — flag for Daniel whether the Cut header keeps the
icon idiom (consistency with Session/Mix) or uses labeled buttons (the literal spec wording).
### 3.3 Data path — the track list
The Cut page needs the release **and its ordered tracks**. Both primitives exist:
- **Release:** `IReleaseDataService.GetById(id)``ReleaseDto` (with `Title`, `Artist`, `Genre`,
`ReleaseDate`, `ImagePath`, `Medium`, `ReleaseType`, `TrackCount`).
- **Tracks:** the track-data service already supports a **`releaseId`-filtered** track page —
`ReleaseDetailViewModel.Load` uses `GetPage(pageNumber: 1, pageSize: 1, releaseId: …)` to resolve
the single track for Session/Mix. The Cut page issues the same call with a **larger page size**
(cover the whole album — `pageSize: 100` matches the gallery convention) to get the ordered list.
**Ordering.** 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.
**New `CutDetailViewModel` vs. extend `ReleaseDetailViewModel`.** `ReleaseDetailViewModel` resolves
*one* track. The Cut page needs *many*. Two options:
- Extend `ReleaseDetailViewModel` with an optional `Tracks` collection populated when the medium is
Cut. Risk: the VM grows a medium conditional — the smell Phase 9 fought.
- A dedicated `CutDetailViewModel` (loads release + full track list). Cleaner SRP; the Cut detail is
genuinely a different shape (multi-track) from the single-release VM.
**Recommend a dedicated `CutDetailViewModel`** + a `CutDetail` page deriving the same prerender-bridge
discipline `ReleaseDetailBase` encodes (persist release + tracks across the prerender→WASM seam,
guard restore on id). If the bridge logic is substantial, consider generalizing `ReleaseDetailBase`'s
bridge into a shared base both single- and multi-track details use — but only if it doesn't force a
medium conditional into the base. Flag the choice; don't pre-commit the implementer.
### 3.4 Play wiring
- **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current,
else `SelectTrackStreaming`).
- **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.
---
## 4. Requirement 3 — normalization and reduction
The brief: eliminate the `/tracks?album` page view; `/tracks` collapses to a pure router; identify
other redundancy.
### 4.1 The `/tracks?album` retirement is gated on §2 and §3
`/tracks?album={title}` has **two live consumers** today:
- `AlbumsView` (`/cuts`) cards → `OpenAlbum``/tracks?album={title}` (line 62).
- `ArchiveView` Cut cards → `DetailHref``/tracks?album={title}` (line 125).
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.
> **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.
### 4.2 What `TrackDetail` becomes
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.
**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)
Surfaced from the read; each is a *candidate*, not a commitment:
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.**
> 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.
---
## 5. Requirement 4 — Archive filters in the URL
**This is a URL-binding pass over the existing `ArchiveView`, borrowing the `TracksView` pattern
verbatim.** No new browser, no new data path — the filter state already drives `LoadReleases`; the
change is making that state **enter and leave via the query string**.
### 5.1 Target URL scheme
```
/archive?q={search}&medium={cut|session|mix}&genre={genre}
```
- All three params optional; omitting one means "no filter on that axis" (matches the current
null-means-all semantics).
- `medium` uses the same lowercase enum token the data service already speaks
(`Medium.ToString().ToLowerInvariant()`), parsed back with `Enum.TryParse(ignoreCase:true)` +
`Enum.IsDefined` — the exact posture `BatchUpload` (§8.E) and the API already use.
- Plain `/archive` (no params) = the unfiltered first page, which is the bridged/prerendered state.
### 5.2 The binding mechanics (borrow `TracksView`)
`TracksView.razor.cs` is the template:
- **In (URL → state):** add `[SupplyParameterFromQuery]` for `q`, `medium`, `genre`. Seed
`SearchText` / `_selectedMedium` / `_selectedGenre` from them in `OnInitializedAsync` **before**
the restore/fetch decision (so a direct nav to a filtered URL fetches filtered, and the bridge
restore is skipped when a filter is active — `ArchiveView` already has `HasActiveFilter` gating
exactly this).
- **Out (state → URL):** each of `OnSearchInput` / `OnMediumSelected` / `OnGenreSelected` calls
`Navigation.NavigateTo($"/archive?{composed query}")` **instead of** (or before) calling
`LoadReleases` directly. The query-param change drives the re-fetch.
### 5.3 The one real subtlety — same-route query change does not re-run `OnInitialized`
Blazor reuses the component on a same-route query-string change and fires `OnParametersSet`, **not**
`OnInitializedAsync` (the `TracksView.ClearFilter` comment, lines 117120, documents exactly this
trap). So the filter→fetch reaction must live where it sees the change:
- **Option A (history-driven):** filter handlers only `NavigateTo` the new URL; move the
state-seeding + `LoadReleases` into `OnParametersSet`/`OnParametersSetAsync` keyed off the query
params. Cleanest — the URL is the single source of truth; back/forward "just works" because each
nav re-runs the same seed-and-fetch. **Recommended.**
- **Option B (dual-write):** handlers both `NavigateTo` *and* `LoadReleases` directly. Simpler diff
but the URL and the fetch are two writes that can drift, and back/forward needs separate handling.
**Recommend Option A.** It makes the URL the source of truth (which is the whole point of the
requirement) and gets shareable links + back/forward correctness as a structural consequence rather
than as bolted-on handling. Guard against the debounce/nav interplay: the search field debounces
(400ms) before firing; ensure a debounced search nav doesn't fight a rapid medium-chip nav (the
`OnParametersSet` reaction should be idempotent on identical param sets — mirror the
`_loadedEntryKey` guard idiom).
### 5.4 Persistence interaction
The bridged unfiltered first page (`PersistKey = "archive-releases"`) must keep restoring **only**
when no filter is active — `ArchiveView` already gates persist + restore on `HasActiveFilter`. The
URL-binding pass must preserve that gate: a `/archive?medium=mix` direct load must **fetch**, not
restore the unfiltered bridge. The existing `HasActiveFilter` check already expresses this; the
seed-from-URL step just has to run before the restore decision (as §5.2 specifies).
---
## 6. Wave decomposition
Sequenced so the structural dependency (Cuts page → resolver → repoint → retire) is honored, and the
two independent tracks (Archive URL; Cuts page) can run in parallel.
```
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}` 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.**
**Critical path:** 11.A → 11.B → 11.C. **11.D is free-floating.**
---
## 7. Open questions and adjacent gaps (need Daniel)
**Decisions inside the four commitments:**
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
`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
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.*
**Adjacent gaps surfaced by the read (not in the four commitments — for the roadmap, not this
phase):**
- **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.*
---
## 8. Why this is consistent with the system's grain
- **Release-cardinal everywhere.** Phase 9 + Wave 8 moved the public site to release-cardinal browse
(`/archive`) and per-medium detail. Phase 11 closes the one hole (Cuts had no detail page) and
makes the player-bar→detail path release-cardinal too. After this, the track-cardinal stack is
vestigial — which is *why* the reduction (§4) is available.
- **One source, multiple views.** The medium→route resolver (§2) is one table consumed by the player
bar, Archive, and Cuts cards — not three `DetailHref` switches. The 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*.)
- **URL as source of truth (§5).** Borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern
makes the Archive's filters addressable, which is the same shareable-link discipline the embed
player and `/tracks?album` deep links already established.
- **Extension, not modification.** The resolver and the `ReleaseDetailScaffold` `Header`/`BodyContent`
slots are additive; a future medium's detail page composes the same scaffold and the resolver gains
one entry — the Phase 9 Open/Closed discipline, unchanged.
---
## 9. Verified facts (read against live source 2026-06-15)
- **No `/tracks/{id}` route exists.** Track-cardinal detail is `/track/{EntryKey}` (`TrackDetail.razor`
line 1). Player-bar title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9).
- **`/sessions/{id}` and `/mixes/{id}` exist and are mature** (`SessionDetail.razor`, `MixDetail.razor`,
both id-addressed, both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes
`ReleaseDetailScaffold`, `SessionDetail` deliberately diverges).
- **Cuts have no single-release detail page.** `/cuts` is `AlbumsView` (medium-parameterized card
grid); cards open `/tracks?album={title}` (`AlbumsView.razor.cs` line 62). `/albums``/cuts`
redirect exists (`AlbumsRedirect.razor`).
- **`/archive` is release-cardinal, filters held in component fields not the URL.** `ArchiveView`
has `_selectedMedium`, `_selectedGenre`, `SearchText` as private state; no `[SupplyParameterFromQuery]`,
no `NavigateTo` on filter change (`ArchiveView.razor.cs`). It has a private `DetailHref` switch
(lines 121126) routing Session→`/sessions/{id}`, Mix→`/mixes/{id}`, Cut→`/tracks?album={title}`.
- **`TracksView` already reads `?album=`/`?genre=`/`?q=` from the URL** via `[SupplyParameterFromQuery]`
(`TracksView.razor.cs` lines 2123) — the pattern requirement 4 borrows. It documents the
same-route-query-change trap (`OnParametersSet` not `OnInitialized`, lines 117120).
- **`ReleaseDetailScaffold`** owns the invariant trio (back link, masthead, play/share) + `Hero` and
`MetaContent` slots; **no body/track-list slot today** (`ReleaseDetailScaffold.razor`).
- **Data primitives for the Cut page both exist:** `IReleaseDataService.GetById(id)` returns a full
`ReleaseDto`; the track-data service supports `releaseId`-filtered paging (`ReleaseDetailViewModel.Load`
uses `GetPage(…, releaseId: …)`). `ReleaseDto` carries `TrackCount` but the **track list needs the
filtered track page** (the DTO has no nested track collection).
- **`TrackEntity` 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.
- **`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.