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:
@@ -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 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.
|
||||
Reference in New Issue
Block a user