Files
deepdrft/product-notes/phase-11-public-site-enhancements.md
T
daniel-c-harvey 913861860b 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.
2026-06-15 23:09:16 -04:00

36 KiB
Raw Blame History

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 NavigateTos the dedicated page (replace: true so the router never sits in history). Cost: a network round-trip and a flash of an empty page on every release-title click, to rediscover a medium the caller already knew. Justified only if some entry point has only a release id and not the medium (e.g. an external deep link to /tracks/{id}). Keep this as a thin fallback, not the primary path.

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

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

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

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.MediumLabelDetailHref 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.