913861860b
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.
552 lines
36 KiB
Markdown
552 lines
36 KiB
Markdown
# 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 21–23).
|
||
|
||
---
|
||
|
||
## 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 121–126); **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 117–120, 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 121–126) 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 21–23) — the pattern requirement 4 borrows. It documents the
|
||
same-route-query-change trap (`OnParametersSet` not `OnInitialized`, lines 117–120).
|
||
- **`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.
|